xtask\tasks\fuzz/
parse_fuzz_crate_toml.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Logic to parse + validate fuzzing-crate `Cargo.toml` files + their
5//! associated folder structure.
6
7use anyhow::Context;
8use std::collections::BTreeMap;
9use std::collections::BTreeSet;
10use std::path::Path;
11use std::path::PathBuf;
12
13#[derive(Debug)]
14pub(super) struct FuzzCrateTarget {
15    pub name: String,
16    pub allowlist: Vec<PathBuf>,
17    pub target_options: Vec<String>,
18}
19
20#[derive(Debug)]
21pub(super) struct FuzzCrateMetadata {
22    pub crate_name: String,
23    pub fuzz_dir: PathBuf,
24    pub targets: Vec<FuzzCrateTarget>,
25}
26
27#[derive(Debug)]
28pub(super) struct RepoFuzzTarget {
29    pub fuzz_dir: PathBuf,
30    #[expect(dead_code)] // useful in `dump` debug output
31    pub crate_name: String,
32    pub allowlist: Vec<PathBuf>,
33    pub target_options: Vec<String>,
34}
35
36// TODO: it would be nice if this function didn't early-bail when it detects an
37// error, and instead keeps validating the file (as best as it can)
38fn parse_fuzz_crate_toml(cargo_toml_path: &Path) -> anyhow::Result<Option<FuzzCrateMetadata>> {
39    let manifest =
40        cargo_toml::Manifest::<super::cargo_package_metadata::PackageMetadata>::from_path_with_metadata(
41            cargo_toml_path,
42        )?;
43
44    // Check if the crate is a HvLite-style cargo-fuzz crate
45    let fuzz_meta = {
46        // ...and simultaneously make sure crates that _aren't_ HvLite-style
47        // cargo-fuzz crates don't misconstrue themselves as such
48        let validate_non_fuzz_crate_name = || {
49            let name = manifest
50                .package
51                .as_ref()
52                .map(|x| x.name())
53                .unwrap_or_default();
54
55            if name.starts_with("fuzz_") {
56                anyhow::bail!("crate '{name}' is named 'fuzz_', but isn't set up to be a fuzzer!")
57            }
58
59            anyhow::Ok(None)
60        };
61
62        // If the crate doesn't have _any_ metadata, it's def not a fuzzing crate
63        let Some(metadata) = manifest.package.as_ref().and_then(|p| p.metadata.as_ref()) else {
64            return validate_non_fuzz_crate_name();
65        };
66
67        // Check to make sure fuzz crates include both the "standard" cargo-fuzz
68        // metadata, and HvLite-specific metadata.
69        match (
70            metadata.cargo_fuzz.unwrap_or(false),
71            metadata.xtask.as_ref().and_then(|x| x.fuzz.as_ref()),
72        ) {
73            (false, None) => return validate_non_fuzz_crate_name(),
74            (true, None) | (false, Some(_)) => {
75                anyhow::bail!(
76                    "`package.metadata.cargo-fuzz` must be paired with `package.metadata.xtask.fuzz`"
77                )
78            }
79            (true, Some(fuzz)) => fuzz,
80        }
81    };
82
83    // cool, we're in a fuzz crate!
84
85    // make sure the fuzz crate is within a directory called "fuzz". this isn't
86    // _strictly_ necessary (`cargo fuzz` supports passing a custom fuzz
87    // directory), but the consistency is nice.
88    if cargo_toml_path
89        .parent()
90        .and_then(|p| p.file_name())
91        .and_then(|p| p.to_str())
92        .unwrap_or_default()
93        != "fuzz"
94    {
95        anyhow::bail!("fuzzing crate Cargo.toml must be in a folder called `fuzz/`")
96    }
97
98    // make sure our fuzz crate naming is consistent, to make it easy to tell
99    // what is / isn't a fuzzing crate.
100    let fuzz_crate_name = manifest
101        .package
102        .as_ref()
103        .map(|p| p.name.as_str())
104        .unwrap_or_default();
105    let Some(fuzz_crate_name) = fuzz_crate_name.strip_prefix("fuzz_") else {
106        anyhow::bail!(r#"fuzzing crate `name` must start with "fuzz_""#)
107    };
108
109    // make sure that [[bin]] is structured correctly
110    let mut bins = BTreeSet::new();
111    for bin in manifest.bin {
112        let name = bin
113            .name
114            .context("found [[bin]] entry without explicit `name` key")?;
115
116        if bin.path.is_none() {
117            anyhow::bail!(r#"found [[bin]] entry (name = {name}) without explicit `path` key"#,)
118        }
119
120        // this isn't just for consistency! it also helps make fuzz target name
121        // collisions across the tree far less likely.
122        if !(name == format!("fuzz_{fuzz_crate_name}")
123            || name.starts_with(&format!("fuzz_{fuzz_crate_name}_")))
124        {
125            anyhow::bail!(
126                r#"invalid [[bin]] entry: invalid name = "{name}". expected `name` to start with "fuzz_{fuzz_crate_name}" (i.e: "fuzz_{{crate_name}}")"#,
127            )
128        }
129
130        for (val, name) in [
131            (bin.test, "test"),
132            (bin.doctest, "doctest"),
133            (bin.doc, "doc"),
134        ] {
135            if val {
136                anyhow::bail!(r#"invalid [[bin]] entry: ensure that `{name} = false`"#)
137            }
138        }
139
140        let was_empty = bins.insert(name);
141        assert!(was_empty); // cargo guarantees that there are no dupes
142    }
143
144    // ensure there is a 1:1 match between each allowlist entry and bin entry
145    {
146        let mut allowlists = fuzz_meta.allowlist.keys().cloned().collect::<BTreeSet<_>>();
147        for bin in bins.iter() {
148            let was_present = allowlists.remove(bin);
149            if !was_present {
150                anyhow::bail!("found [[bin]] that doesn't have an allowlist: {bin}")
151            }
152        }
153        if !allowlists.is_empty() {
154            anyhow::bail!(
155                "found allowlist entries that doesn't corresponding [[bin]] entries: {allowlists:?}"
156            )
157        }
158    }
159
160    let targets = {
161        let mut targets = Vec::new();
162        for (target_name, allowlist) in &fuzz_meta.allowlist {
163            // normalize allowlist globs using `glob` to avoid taking a
164            // dependance on the funky OneFuzz allowlist format
165            let mut normalized_allowlist = Vec::new();
166            let mut normalized_ignorelist = BTreeSet::new();
167
168            let (allowed_globs, ignored_globs): (Vec<_>, Vec<_>) =
169                allowlist.iter().partition(|s| !s.starts_with('!'));
170
171            let normalize_glob = |glob: &str| {
172                let mut normalized_paths = Vec::new();
173
174                let anchored_glob = cargo_toml_path.parent().unwrap().join(glob);
175                let paths = glob::glob(&anchored_glob.to_string_lossy())
176                    .context(format!("'{target_name}' has invalid allowlist glob format"))?;
177
178                for path in paths {
179                    let path =
180                        std::path::absolute(path?).context("failed to make path absolute")?;
181
182                    if path.is_dir() {
183                        continue;
184                    }
185
186                    normalized_paths.push(path);
187                }
188
189                anyhow::Ok(normalized_paths)
190            };
191
192            for glob in ignored_globs {
193                normalized_ignorelist.extend(normalize_glob(glob.strip_prefix('!').unwrap())?)
194            }
195
196            for glob in allowed_globs {
197                for path in normalize_glob(glob)? {
198                    if !normalized_ignorelist.contains(&path) {
199                        normalized_allowlist.push(path)
200                    }
201                }
202            }
203
204            if normalized_allowlist.is_empty() {
205                anyhow::bail!("'{target_name}' has allowlist that matches no files")
206            }
207
208            targets.push(FuzzCrateTarget {
209                name: target_name.clone(),
210                allowlist: normalized_allowlist,
211                target_options: fuzz_meta
212                    .target_options
213                    .get(target_name)
214                    .cloned()
215                    .unwrap_or_default(),
216            })
217        }
218        targets
219    };
220
221    let fuzz_crate_metadata = FuzzCrateMetadata {
222        crate_name: manifest.package.as_ref().map(|x| x.name.clone()).unwrap(),
223        fuzz_dir: cargo_toml_path.parent().unwrap().into(),
224        targets,
225    };
226
227    Ok(Some(fuzz_crate_metadata))
228}
229
230pub(super) fn get_repo_fuzz_crates(
231    ctx: &crate::XtaskCtx,
232) -> anyhow::Result<Vec<FuzzCrateMetadata>> {
233    let cargo_tomls = ignore::Walk::new(&ctx.root).filter_map(|entry| match entry {
234        Ok(entry) if entry.file_name() == "Cargo.toml" => Some(entry.into_path()),
235        Err(err) => {
236            log::error!("error when walking over subdirectories: {}", err);
237            None
238        }
239        _ => None,
240    });
241
242    let mut fuzz_crates = Vec::new();
243    let mut errors = Vec::new();
244    for path in cargo_tomls {
245        match parse_fuzz_crate_toml(&path) {
246            Ok(None) => {}
247            Ok(Some(meta)) => fuzz_crates.push(meta),
248            Err(e) => errors.push(e.context(format!("in {}", path.display()))),
249        }
250    }
251
252    if !errors.is_empty() {
253        for e in &errors {
254            log::error!("{:#}", e);
255        }
256        anyhow::bail!("failed to verify in-tree fuzzers")
257    }
258
259    Ok(fuzz_crates)
260}
261
262pub(super) fn get_repo_fuzz_targets(
263    fuzz_crates: &[FuzzCrateMetadata],
264) -> anyhow::Result<BTreeMap<String, RepoFuzzTarget>> {
265    let mut fuzz_targets = BTreeMap::new();
266    for FuzzCrateMetadata {
267        fuzz_dir,
268        targets,
269        crate_name,
270    } in fuzz_crates
271    {
272        // if two fuzz crates happen to have the same fuzz target name,
273        // whichever crate happens to be built last will override the crate
274        // built earlier, which is very bad.
275        for FuzzCrateTarget {
276            name,
277            allowlist,
278            target_options,
279        } in targets
280        {
281            let existing = fuzz_targets.insert(
282                name.clone(),
283                RepoFuzzTarget {
284                    crate_name: crate_name.clone(),
285                    fuzz_dir: fuzz_dir.clone(),
286                    allowlist: allowlist.clone(),
287                    target_options: target_options.clone(),
288                },
289            );
290
291            if let Some(existing) = existing {
292                anyhow::bail!(
293                    "cannot have two targets with the same name: {} (in {} and {})",
294                    name,
295                    fuzz_dir.display(),
296                    existing.fuzz_dir.display()
297                )
298            }
299        }
300    }
301
302    Ok(fuzz_targets)
303}